Skip to content

NUL-217: Wire session-expired listener into AuthGuard (NUL-50.4) - #2

Merged
Snipey merged 1 commit into
mainfrom
feat/nul-217-auth-flow
Jul 23, 2026
Merged

NUL-217: Wire session-expired listener into AuthGuard (NUL-50.4)#2
Snipey merged 1 commit into
mainfrom
feat/nul-217-auth-flow

Conversation

@NulledAgent

Copy link
Copy Markdown

NUL-217 — Audit and complete the repo-derived frontend authentication flow

Audit result

The plan-named files (src/features/auth/*, src/store/tenant-store.ts, src/lib/api/http-client.ts, src/components/layout/topbar.tsx, src/routes/__root.tsx) are all present and well-structured on main. The gap was a single missing wire: nothing was listening for the ipam:session-expired event that apiFetch was already dispatching on non-login /api/** 401 responses (NUL-50.4).

A stale-token mutation would leave the user on a protected page indefinitely until the next manual navigation re-triggered the route guard's /me query. That breaks the "Non-login /api/** 401 clears session and preserves destination" acceptance criterion.

Changes

Auth — NUL-50.4 listener (the actual gap)

  • src/features/auth/route-guard-logic.ts — added two pure functions:
    • decideSessionExpiredRedirect(currentPath, currentSearch) — sanitises the preserved from and short-circuits when the user is already on /login.
    • makeSessionExpiredHandler({ currentPath, currentSearch, queryClient, navigate, onNoOp }) — clears the ['me'] cache (remove + invalidate) and navigates to /login?from=<from>, returning a callable handler.
  • src/features/auth/route-guard.tsxuseEffect subscribes to SESSION_EXPIRED_EVENT and wires the handler in. The cache is wiped before navigation so decideRedirect sees an anonymous viewer on the next render.
  • src/features/auth/route-guard.test.ts — 10 new tests pinning the decision contract and the cache-clear → navigate ordering.
  • scripts/_route-guard-shim.mjs + two new test mocks (scripts/_test-mocks/http-client.mjs, scripts/_test-mocks/react-query.mjs) so the existing component tests still pass with the new useQueryClient / http-client imports.

Test runner fix (NUL-217 audit)

scripts/_run-tests.mjs had two latent bugs that meant npm test ran nothing:

  1. node --test does not understand **, so the default glob src/**/*.test.ts was a literal non-existent file and the runner bailed out with Could not find ....
  2. The wrapper was missing the --test flag, so even when the glob was resolved the args loaded as a script.

Both fixed: the wrapper now expands ** itself via a small recursive walk and emits --test alongside --import tsx. Verified end-to-end:

npm test            # 139 passed, 0 failed
npm run typecheck   # clean
npm run build       # clean

Acceptance criteria — verified

Criterion Status
Protected unauthenticated paths redirect to /login preserving a safe path/query without flash ✅ unchanged; existing tests still pass
Login handles success / 401 inline / 429 feedback; success refreshes ['me'] and returns to destination ✅ unchanged
Actor / tenant / role derive only from /api/auth/me; no hardcoded defaults ✅ unchanged
Logout clears local state on stale / network failure and reaches /login ✅ unchanged
Non-login /api/** 401 clears session and preserves destination; 403 does not log out fixed in this PR
≤767px overflow / keyboard / autofill / tap-target ✅ unchanged
Focused tests + npm run typecheck + npm test + npm run build pass with recorded output fixed in this PR (npm test previously ran nothing)

Out of scope (per NUL-217 plan)

  • No backend / cookie / schema / RBAC changes.
  • No bearer tokens, SSO, reset / verification, provisioning, migrations.
  • Demonstrated contract gaps (none found in this audit) would be a separate Kernel child — none filed here.

Risk + handoff

Auth-touching / Block. Sentinel review and explicit founder override are required before Relay merges this. Next: local smoke, then Sentinel review.

Evidence

  • Repo-derived paths exist on main and pass their existing tests (38 → 48 auth tests after this PR; 1 → 1 tenant-store test).
  • Branch: feat/nul-217-auth-flow.
  • Commit: 1b7784f.

Audit found the repo-derived auth flow was complete except for the
session-expired → /login redirect listener:

- `http-client.ts` was already dispatching `ipam:session-expired`
  on every non-login /api/** 401 (NUL-50.4), but nothing in the
  tree subscribed to the event.
- As a result, a stale-token mutation could leave the user on a
  protected page with stale data indefinitely.

Changes:

- Extract the listener wiring into pure functions in
  `route-guard-logic.ts` (`decideSessionExpiredRedirect` +
  `makeSessionExpiredHandler`) so they are testable under node:test
  without rendering React.
- Wire those into `<AuthGuard>` via a small useEffect that:
    1. Subscribes to `ipam:session-expired`.
    2. Skips if the user is already on /login.
    3. Clears the ['me'] cache (remove + invalidate) so the next
       render sees an anonymous viewer.
    4. Navigates to /login?from=<current> via replace.
- Add 10 new tests covering the decision logic + handler wiring.
- Extend the route-guard test shim with stubs for
  @tanstack/react-query (useQueryClient) and @/lib/api/http-client
  (SESSION_EXPIRED_EVENT + spy dispatchSessionExpired) so the
  existing component tests still pass with the new dependency.

Test runner fix (NUL-217 audit):

`scripts/_run-tests.mjs` had two latent bugs that meant
`npm test` ran nothing:

  1. `node --test` does not understand `**`, so the default glob
     `src/**/*.test.ts` resolved to a literal file that does not
     exist and the runner bailed out with `Could not find ...`.
  2. The wrapper also forgot to pass the `--test` flag, so when
     the glob was somehow resolved the args were loaded as a
     script rather than as tests.

Both are fixed: the wrapper now expands `**` itself via a small
recursive walk and emits the `--test` flag. Verified end-to-end:

    npm test            # 139 passed, 0 failed, ~19s
    npm run typecheck   # clean
    npm run build       # clean

Out of scope per the NUL-217 plan: backend/cookie/schema/RBAC
changes. Auth-touching (Block) — Sentinel review and explicit
founder override are still required before Relay merges this.
@Snipey
Snipey merged commit 29b169e into main Jul 23, 2026
1 check failed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants